import Link from "next/link"; import { notFound } from "next/navigation"; import { ArrowLeft, ArrowRight, KeyRound } from "lucide-react"; import { PLAN_LIMITS, normalizePlan } from "@fetcha/core"; import { getWorkspace } from "@/lib/session"; import { getOrgProject, listApiKeys, monthlyUsage } from "@/lib/queries/account"; import { formatDate, formatDateOnly, formatNumber, formatPercent, formatUsd, timeAgo } from "@/lib/format"; import { maskedKey } from "@/lib/account-snippets"; import { PageHeader, SectionTitle } from "@/components/ui/page-header"; import { Badge, StatusBadge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Stat, StatGrid } from "@/components/ui/stat"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { EmptyState } from "@/components/ui/empty-state"; import { Alert } from "@/components/ui/alert"; import { ProjectForm } from "@/components/dashboard/projects/project-form"; import { ArchiveProjectButton, SelectProjectButton } from "@/components/dashboard/projects/project-actions"; import { CreateKeyDialog } from "@/components/dashboard/api-keys/create-key-dialog"; export const dynamic = "force-dynamic"; export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const ws = await getWorkspace(); const project = await getOrgProject(ws.organization.id, id); if (!project) notFound(); const [usage, keys] = await Promise.all([monthlyUsage(ws.organization.id, project.id), listApiKeys(ws.organization.id, project.id)]); const activeKeys = keys.filter((k) => k.status === "active"); const current = ws.project.id === project.id; const archived = Boolean(project.archivedAt); const planLimits = PLAN_LIMITS[normalizePlan(ws.organization.plan)]; const requestCap = project.monthlyRequestLimit ?? planLimits.monthly_requests; const requestPct = requestCap > 0 && requestCap < Number.MAX_SAFE_INTEGER ? Math.min(100, (usage.requests / requestCap) * 100) : null; const spendPct = project.hardLimitUsd ? Math.min(100, (usage.spendUsd / project.hardLimitUsd) * 100) : null; return (
All projects
{project.id}} title={ {project.name} {project.environment} {current ? ( Current ) : null} {archived ? : null} } description={project.description || `Created ${formatDate(project.createdAt)}.`} actions={ <> {!current && !archived ? ( Make current ) : null} } /> {archived ? Its keys no longer authenticate and it is hidden from the project switcher. Contact support@fetcha.co to restore it. : null}
Project settings Defaults apply to requests that omit the field. Limits are enforced per calendar month.
Manage keys } > API keys ยท {keys.length} {keys.length === 0 ? ( : undefined} /> ) : (
Name Key Mode Last used Status {keys.slice(0, 8).map((k) => ( {k.name} {maskedKey(k.keyPrefix, k.last4)} {k.mode} {timeAgo(k.lastUsedAt)} ))}
{keys.length > 8 ? (
View all {keys.length} keys
) : null}
)}
Spending limits Month-to-date against this project's limits.
Soft limit (alert) {project.softLimitUsd ? formatUsd(project.softLimitUsd) : "โ€”"}

Organization-wide limits live in{" "} Settings .

{!archived ? ( Danger zone Archiving hides the project and disables its keys. ) : null}
); } function LimitRow({ label, value, pct, note }: { label: string; value: string; pct: number | null; note: string }) { return (
{label} {value}
{pct !== null ?
= 90 ? "h-full bg-danger" : pct >= 70 ? "h-full bg-warning" : "h-full bg-accent"} style={{ width: `${pct}%` }} /> : null}
{note}
); }